Error-Free C# Part II: Functional Data Processing
csharp monadMutability bugs and thread-unsafety are big problems in data processing. Fortunately, the .NET Framework has strong support for immutable collections, eliminating entire categories of bugs. This post will show how to use extension methods to create even safer ways to interact with with lists in C# by building on the IMaybe monad type we created in the previous post in this series.
Prerequisite: System.Collections.Immutable
This post builds on the lovely Systems.Collections.Immutable library in C#. Much digital ink has already been spilt in praise of the concept of immutable collections, and I don't have anything to add, but if you're not yet familiar, I recommend reading this detailed post on the benefits thereof.
This post will also use extension methods. You don't necessarily need to already be familiar with extension methods, but if you need a supplement, I recommend this Microsoft article.
The need for safety in data processing
C#'s immutable collections lead us well on our way on our functional
programming journey, but it gives us little protection at the margins. How do
we protect ourselves in case of empty lists? How can you be sure that you
have enough try
/catch
blocks to handle the dreaded
ArgumentOutOfRangeException?
These issues are every bit as
avoidable
as NullReferenceException
, and with a little extension method
magic, we'll soon forget we ever had them.
Set up the extension methods class
Start by instantiating a new class. Extension methods must live in static
classes, and, by convention, that static class should end in "Extensions". I'm
going to call mine EnumerableExtensions and apply it to the broad
IEnumerable<T>
interface (of which IImmutableList<T>
is an implementation).
EnumerableExtensions.cs
public static class EnumerableExtensions
{
}
Extension method signatures
Next, let's write the signature for our extension methods. Extension method
signatures use the this
keyword in the parameter list, which allows us to use
it as a normal object method instead of a static method.
public static class EnumerableExtensions
{
public static IMaybe<T> MaybeFirst<T>(this IEnumerable<T> xs)
=> throw new NotImplementedException();
public static IMaybe<T> MaybeLast<T>(this IEnumerable<T> xs)
=> throw new NotImplementedException();
}
The safest way to wrap methods
Now let's implement methods, performing checks here so we'll be sure to have safely wrapped monads.
public static class EnumerableExtensions
{
public static IMaybe<T> MaybeFirst<T>(this IEnumerable<T> xs)
=> xs.Any() ? Maybe.Factory<T>(xs.First()) : new None<T>();
public static IMaybe<T> MaybeLast<T>(this IEnumerable<T> xs)
=> xs.Any() ? Maybe.Factory<T>(xs.Last()) : new None<T>();
}
There are a few ways we could have handled these two cases. We could have used
Count()
to see if the count is greater than one, but if the IEnumerable
is very
massive, it could take a long time to execute. We could also have wrapped the
operation in a try
and caught System.InvalidOperationException
, but
throwing an exception just to catch it is somewhat computationally expensive.
We could also have called Maybe.Factory(...)
directly using
FirstOrDefault()
, but this would have produced unexpected results if the
default value of the type isn't null
, as is the case with primitive-like
types like DateTime
and int
.
Any()
, then, is the best practice here. So as long as we stick to our
"Maybe" extension methods and avoid First()
and Last()
, we can feel
confident that we are always using the best practice.
We've just eliminated System.InvalidOperationException
.
Eliminating System.ArgumentOutOfRangeException
from ElementAt()
While we're here, let's add one more extension method to eliminate exceptions
coming from ElementAt()
. ElementAt()
fails if there are fewer elements than
the requested index.
In this case, Any()
doesn't help us, and Count()
is still pretty
computationally expensive, so for this, simply catching the exception will
suffice.
public static class EnumerableExtensions
{
public static IMaybe<T> MaybeElementAt<T>(this IEnumerable<T> xs, int i)
{
try { return Maybe.Factory<T>(xs.ElementAt(i)); }
catch { return new None<T>(); }
}
public static IMaybe<T> MaybeFirst<T>(this IEnumerable<T> xs)
=> xs.Any() ? Maybe.Factory<T>(xs.First()) : new None<T>();
public static IMaybe<T> MaybeLast<T>(this IEnumerable<T> xs)
=> xs.Any() ? Maybe.Factory<T>(xs.Last()) : new None<T>();
}
Never worry about missing data again.
As long as we stick to our extension methods, we always know we're using the safest, most efficient, and most correct way to get specific elements of a list.
This concludes the part of the series on IMaybe monads and eliminating exceptions relating to missing expected data. In my next post, I'll work on eliminating other kinds of runtime exceptions using a special kind of monad called a try monad.
I write to learn, so I welcome your constructive criticism. Report issues on GitLab.