Saturday, October 29, 2011

Poor man's option type in C#

I've blogged before about the virtues of the Option type. However, it's currently not part of the standard .NET library, so either you have to use F#, code it yourself, or use a library.

But there is something built-in and almost equivalent: lists! You can use any list-like container (List<T>, T[]) as an option type, and all operations are already built-in.

Option has None (no value) and Some (a value). This corresponds exactly to an empty list and a singleton list respectively. Let's see how we map operations on Options (using FSharpx) to operations on arrays using standard .NET 3.5:

 

Option Array / IEnumerable
Constructor: Some
FSharpOption<int>.Some(5)
or:
5.Some()
new[] { 5 }
Constructor: None
FSharpOption<int>.None
new int[0]
Check if the option has a value
FSharpOption<int> o = ...
bool hasValue = o.HasValue();
IEnumerable<int> o = ...
bool hasValue = o.Any();
Get the value associated with the option
FSharpOption<int> o = ...
int value = o.Value;
IEnumerable<int> o = ...
int value = o.First();
Pattern matching
FSharpOption<int> o = ...
int b = o.Match(x => x + 2, () => 99);
IEnumerable<int> o = ...
int b = o.Aggregate(99, (_, x) => x + 2);
or lazier:
int b = singleVersion.Any() ? singleVersion.First() + 2 : 99;

And thanks to LINQ, you also get mapping and monadic syntax for free. Remember that code I refactored to monads a while ago? Here's the last part of it side-by-side with a translation using Array/IEnumerable:

var maxVersion = L.F((string[] parts) => {
    var p = parts.Length == 2 ? parts[1] : parts[0];
    if (string.IsNullOrWhiteSpace(p))
        return FSharpOption<FSharpOption<Version>>.Some(FSharpOption<Version>.None);
    return ParseVersion(p).Select(v => v.ToOption());
});

var singleVersion =
    from v in ParseVersion(value)
    select (IVersionSpec) new VersionSpec {IsMinInclusive = true, MinVersion = v};

var versionRange = L.F(() => from x in checkLength(value)
                             from isMin in minInclusive(value)
                             from isMax in maxInclusive(value)
                             let val = value.Substring(1, value.Length - 2)
                             let parts = val.Split(',')
                             from y in checkParts(parts)
                             from min in minVersion(parts)
                             from max in maxVersion(parts)
                             select (IVersionSpec) new VersionSpec {
                                 IsMinInclusive = isMin,
                                 MinVersion = min.HasValue() ? min.Value : null,
                                 IsMaxInclusive = isMax,
                                 MaxVersion = max.HasValue() ? max.Value : null,
                             });

return singleVersion.OrElse(versionRange)();
var maxVersion = L.F((string[] parts) => {
    var p = parts.Length == 2 ? parts[1] : parts[0];
    if (string.IsNullOrWhiteSpace(p))
        return new[] {new Version[0]};
    return ParseVersion(p).Select(v => new[] {v});
});

var singleVersion =
    from v in ParseVersion(value)
    select (IVersionSpec) new VersionSpec {IsMinInclusive = true, MinVersion = v};

var versionRange = L.F(() => from x in checkLength(value)
                             from isMin in minInclusive(value)
                             from isMax in maxInclusive(value)
                             let val = value.Substring(1, value.Length - 2)
                             let parts = val.Split(',')
                             from y in checkParts(parts)
                             from min in minVersion(parts)
                             from max in maxVersion(parts)
                             select (IVersionSpec) new VersionSpec {
                                 IsMinInclusive = isMin,
                                 MinVersion = min.Any() ? min.First() : null,
                                 IsMaxInclusive = isMax,
                                 MaxVersion = max.Any() ? max.First() : null,
                             });

return singleVersion.Any() ? singleVersion : versionRange();

If you want to compare the whole code: here's the original (using option) and here's the one using arrays.

You've even probably used this already, perhaps without realizing this relation. However, while an option type can either have exactly one or zero value, an array can have any number of values. And if you see a method returning an IEnumerable<T>, you wouldn't think you're supposed to treat it as an option.

So IEnumerable<T> as a monad (the List monad, that is) is sort of an extension of the option type (i.e. the Maybe monad): instead of just supporting one successful computation, it supports many. I think using the List monad as an Option is acceptable locally, and only if you can't use a proper option type or for some reason don't want to take the dependency on a library. It's a useful hack, but still a hack. They're different things really.

2 comments:

Steve Gilham said...

It looks like this was an idea waiting to be (re-?)discovered.

The notion came to me after I'd been tidying up some FxCop rule code where there's a lot of nested "if a property declared as a base type is of a certain derived type then use the derived type". I looked at the null-object pattern as a way to avoid the explicit null checks and ended up with the OfType<T>().Select() chain to avert the arrow anti-pattern.

Then a few days after the fact, when it dawned on me that this was yet another case of "if monads did not exist, we would have to invent them", and, having not spotted this post in a search for Maybe monads in C#, wrote it up.

Mauricio Scheffer said...

@Steve : Interesting, I didn't see the relation with the null-object pattern!
It seems we both agree that this is a poor-man's option type/maybe monad, so why not give FSharpx a try? ;-)